Skip to content

Conversation

@Han5991
Copy link
Contributor

@Han5991 Han5991 commented Oct 7, 2025

Description

This PR fixes a bug where the removeListener event was not being emitted when the last listener was removed from an EventEmitter.

Background

When removeListener() is called and the last listener is removed (_eventsCount === 0), the code path that emits the removeListener event
was being skipped. This was because the emission logic was inside the else block of the event count check.

This issue particularly affects once() listeners, which automatically call removeListener() after execution. If the once listener was the
last listener, the removeListener event would not fire.

Changes

  • lib/events.js: Moved the removeListener event emission outside the conditional block to ensure it always executes when a listener is
    removed
  • test/parallel/test-event-emitter-remove-listeners.js: Added test coverage for once() listeners with removeListener event monitoring

Test Plan

./node test/parallel/test-event-emitter-remove-listeners.js
make test-ci

All tests pass, including the new test case that verifies removeListener events are emitted when once() listeners are automatically removed.

Checklist

- tests are included
- documentation is not affected
- make -j4 test passes locally
image

Related Issues

fixes: #59977

@nodejs-github-bot nodejs-github-bot added events Issues and PRs related to the events subsystem / EventEmitter. needs-ci PRs that need a full CI run. labels Oct 7, 2025
@Han5991 Han5991 marked this pull request as ready for review October 7, 2025 05:54
@codecov
Copy link

codecov bot commented Oct 7, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.53%. Comparing base (bfc81ca) to head (d322e4d).

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #60137      +/-   ##
==========================================
- Coverage   88.55%   88.53%   -0.02%     
==========================================
  Files         704      704              
  Lines      208087   208088       +1     
  Branches    40019    40014       -5     
==========================================
- Hits       184266   184233      -33     
- Misses      15818    15862      +44     
+ Partials     8003     7993      -10     
Files with missing lines Coverage Δ
lib/events.js 99.83% <100.00%> (+<0.01%) ⬆️

... and 25 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Adds test coverage for the removeListener event being emitted
when a once() listener is automatically removed after execution.
This verifies that streams and other EventEmitters correctly
emit removeListener events when once() wrappers clean up.
When the last listener is removed and _eventsCount becomes 0,
the removeListener event was not being emitted because the check
was inside the else block. This moves the removeListener emission
outside the conditional to ensure it always fires when a listener
is removed.
@Han5991 Han5991 force-pushed the fix-events-remove-listener-emission branch from 074f626 to d322e4d Compare October 9, 2025 23:24
Copy link

@simonkcleung simonkcleung left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could delete 2 more lines:

del 690-691, 715-716

+718 if (events.removeListener !== undefined)
+719 this.emit('removeListener', type, listener);

@Han5991
Copy link
Contributor Author

Han5991 commented Oct 22, 2025

Could delete 2 more lines:

del 690-691, 715-716

+718 if (events.removeListener !== undefined) +719 this.emit('removeListener', type, listener);

Delete lines 690-691, 715-716, and add consolidated emit at 718-719.

The Problem I Found

When I tried this, the test failed because:

  • Line 704-705: if (position < 0) return this; - early return when listener
    not found
  • If we move emit to line 718, it would never execute after this early return
  • But we need it to execute when removal succeeds in the array case

Current State

I kept both emit calls inside their respective branches:

  • Line 692-693: emit after single listener removal
  • Line 716-717: emit after array listener removal

Your consolidation proposal cannot work due to the early return at line
704-705. The emit must happen before the function can return.

@simonkcleung
Copy link

Sorry if I am wrong.
if (position < 0) return this; means - if no listener found in the list, then do nothing.
Because there is no listener being removed, Not emitting 'removeListener' event is fine.
If the test failed, it should also failed before the change.

And I think

if (list === listener || list.listener === listener) {
... ...
if (events.removeListener !== undefined)
 this.emit('removeListener', type, listener);
} else if (typeof list !== 'function') {
... ...
if (events.removeListener !== undefined)
 this.emit('removeListener', type, listener);
}

is same as

if (list === listener || list.listener === listener) {
...
} else if (typeof list !== 'function') {
...
}
if (events.removeListener !== undefined)
 this.emit('removeListener', type, listener);

@Han5991
Copy link
Contributor Author

Han5991 commented Oct 23, 2025

Sorry if I am wrong. if (position < 0) return this; means - if no listener found in the list, then do nothing. Because there is no listener being removed, Not emitting 'removeListener' event is fine. If the test failed, it should also failed before the change.

And I think

if (list === listener || list.listener === listener) {
... ...
if (events.removeListener !== undefined)
 this.emit('removeListener', type, listener);
} else if (typeof list !== 'function') {
... ...
if (events.removeListener !== undefined)
 this.emit('removeListener', type, listener);
}

is same as

if (list === listener || list.listener === listener) {
...
} else if (typeof list !== 'function') {
...
}
if (events.removeListener !== undefined)
 this.emit('removeListener', type, listener);

@simonkcleung Thank you for the suggestion! I explored consolidating the emit calls, but found a issue:

Edge Case Problem:
If both conditions fail (listener not found), the code would still emit removeListener event incorrectly.

Test case (line 42-48):

ee.on('hello', listener1);
ee.on('removeListener', common.mustNotCall());
ee.removeListener('hello', listener2);  // listener2 never added - should NOT emit

Performance:
Consolidation would require a tracking variable (let removed or let removedListener), adding overhead to every removeListener() call.

The current approach with duplicate emit calls is actually optimal:
- Zero overhead (no extra variables)
- Handles edge cases correctly
- Properly handles wrapped listeners (list.listener || listener vs listener)

I've kept the duplicate emits while fixing the original bug. Thanks for the detailed review!

@simonkcleung
Copy link

simonkcleung commented Oct 24, 2025

@simonkcleung Thank you for the suggestion! I explored consolidating the emit calls, but found a issue:

Edge Case Problem: If both conditions fail (listener not found), the code would still emit removeListener event incorrectly.

Test case (line 42-48):

ee.on('hello', listener1);
ee.on('removeListener', common.mustNotCall());
ee.removeListener('hello', listener2);  // listener2 never added - should NOT emit

Performance:
Consolidation would require a tracking variable (let removed or let removedListener), adding overhead to every removeListener() call.

The current approach with duplicate emit calls is actually optimal:
- Zero overhead (no extra variables)
- Handles edge cases correctly
- Properly handles wrapped listeners (list.listener || listener vs listener)

I've kept the duplicate emits while fixing the original bug. Thanks for the detailed review!

You are right.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

events Issues and PRs related to the events subsystem / EventEmitter. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sockets no longer emit removeListener events in v20.11.0

3 participants